Skip to content

Feature/community tables (feat: 커뮤니티 테이블과 게시물 엔티티 추가) - #78

Merged
SD-gif merged 26 commits into
mainfrom
feature/community-tables
Aug 25, 2026
Merged

Feature/community tables (feat: 커뮤니티 테이블과 게시물 엔티티 추가)#78
SD-gif merged 26 commits into
mainfrom
feature/community-tables

Conversation

@sususuj

@sususuj sususuj commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

⏺ 변경 내용

커뮤니티 기능 전체입니다. DB 스키마, API 31개, 문서, 테스트를 포함합니다.

DB — V8__create_community_tables.sql

테이블 12개, 인덱스 9개를 추가합니다. users는 V6(PR #45)에서 추가했습니다.

  • 게시물 — posts, post_media, post_place_tags
  • 댓글 — comments
  • 반응 — post_likes, comment_likes, bookmarks
  • 관계 — follows, blocks
  • 해시태그 — hashtags, post_hashtags
  • 신고 — reports

API 31개

  • 게시물 6 — 작성, 피드, 인기 피드, 상세, 수정, 삭제
  • 좋아요 2 — 누르기, 취소
  • 댓글 5 — 작성, 목록, 삭제, 좋아요, 취소
  • 북마크 3 — 저장, 해제, 내 목록
  • 팔로우 4 — 팔로우, 취소, 팔로워 목록, 팔로잉 목록
  • 차단 3 — 차단, 해제, 내 목록
  • 프로필 5 — 조회, 게시물 목록, 닉네임 변경, 사진 변경, 사진 제거
  • 검색 2 — 사용자 검색, 해시태그 자동완성
  • 신고 1 — 접수

PR이 큽니다. 코드를 다 보시기 부담되면 docs/API_SPEC.md의 커뮤니티 계약과 docs/ERD.md 14~26번만 봐주셔도 설계 의도는 파악되실 겁니다.

임시 조치 — X-User-Id 헤더

인증이 없어 작성자를 X-User-Id 헤더로 받습니다. 인증 도입 시 반드시 제거해야 합니다. 지금 상태로 운영에 올라가면 헤더만 바꿔 다른 사용자를 사칭할 수 있습니다. 컨트롤러마다 TODO 주석을 남겼습니다.
본문이 아니라 헤더로 받은 이유는, 조회 API에는 본문이 없어 방식이 갈라지고 나중에 Authorization 헤더로 교체할 때 DTO를 건드리지 않아도 되기 때문입니다.

주요 설계 판단

  • 집계 컬럼은 DB에서 직접 증감시킵니다. UPDATE ... SET like_count = like_count + 1 방식입니다. 엔티티를 읽어 고쳐 쓰면 동시 요청에서 하나가 사라집니다.

  • 좋아요·저장·팔로우·차단은 멱등합니다. 중복 요청에도 개수가 어긋나지 않습니다.

  • 소프트 삭제에 @where를 쓰지 않았습니다. 기획상 게시물은 삭제 후 30일간 복구할 수 있어야 하는데, 전역 필터를 걸면 삭제된 글을 조회할 수단이 사라집니다. 조회 메서드마다 deleted_at IS NULL을 명시했습니다.

  • 삭제된 댓글이 목록에 남을 수 있습니다. 살아 있는 답글이 있으면 자리를 유지하고 작성자·내용을 감춥니다. 부모가 사라지면 답글이 함께 안 보이기 때문입니다.

  • 복합 PK 순서를 조회 방향에 맞췄습니다. bookmarks만 (user_id, post_id)로, 주 용도가 "내 북마크 목록"이라 사용자 기준 조회가 많습니다. 덕분에 별도 인덱스가 필요 없습니다.

  • 차단은 단방향입니다. 역방향까지 막으려면 모든 조회에서 역방향 확인이 필요해 비용이 큽니다.

  • PostPlaceTagView로 N+1을 막았습니다. 장소 태그를 Place 엔티티로 읽으면 mappedBy로 연결된 place_details, place_operating_infos가 장소마다 조회를 일으킵니다. 피드 5건 기준 13회 → 3회로 줄었습니다.

  • 페이징이 두 가지입니다. 커서를 만들 수 없는 곳(점수 정렬, 대리키 없는 테이블)만 오프셋을 씁니다.

    알려진 문제
    요청 본문 검증 실패 시 GlobalExceptionHandler.validationErrorCode()가 URI로 코드를 고르는데 커뮤니티 분기가 없어INVALID_SCHEDULE_CONDITION("일정 조건이 올바르지 않습니다")이 나갑니다.
    fieldErrors는 정확합니다. 핸들러에 /api/v1/posts, /api/v1/users 분기를 추가하면 해결되는데, 공용 파일이라 임의로 고치지 않았습니다. 추가해도 될까요?

    마이그레이션 번호
    작업 중 V7__add_schedule_stop_times.sql이 먼저 머지되어 번호가 겹쳐 V8로 옮겼습니다.

    영향

    • API 변경 없음
    • API 문서와 변경 이력 수정
    • DB·ERD 변경 없음
    • ERD와 필요한 migration 수정
    • 외부 API 영향 확인

    docs/API_SPEC.md, docs/ERD.md, docs/api-change-log.md를 모두 갱신했습니다. 외부 API는 사용하지 않습니다.

테스트

./gradlew test

BUILD SUCCESSFUL in 51s

140건 통과 (기존 113 + 신규 26), 실패 0, 에러 0

docker compose -f docker-compose.local.yml down -v
docker compose -f docker-compose.local.yml up -d
./gradlew bootRun --args='--spring.profiles.active=local'

Successfully applied 8 migrations, now at version v8

Started ServerApplication (ddl-auto=validate 통과)

신규 테스트 26건이 검증하는 내용입니다.

  • HashtagExtractorTest 11건 — 띄어쓰기·특수문자에서 끊김, 소문자 정규화, 중복 제거, 20개·50자 제한
  • PostServiceTest 8건 — 좋아요 멱등성, 권한 403, 삭제 시 행 보존, 해시태그 재계산, 팔로잉 피드 요청자 검증
  • CommentServiceTest 7건 — 답글에 답글 거부, 소속 검증, 삭제된 부모의 자리 유지, 댓글 좋아요 멱등성

로컬에서 실제 호출로 확인한 내용입니다.

  • 게시물 작성 시 미디어·장소 태그·해시태그가 한 트랜잭션으로 저장
  • 커서 페이징 3페이지 왕복 (5,4 → 3,2 → 1 → nextCursor: null)
  • 팔로잉·장소·해시태그 필터와 조합 조회
  • 차단 시 피드에서 제외되고 팔로우가 자동 해제
  • 인기 피드 점수 순 정렬
  • 피드 쿼리 수 5회 고정 (게시물 수와 무관)

확인

  • 비밀값이 포함되지 않음
  • 관련 문서와 구현이 일치함
  • 의도하지 않은 변경이 없음

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

🤖 Gemini PR Review

⚠️ PR 변경량이 커서 Diff 일부만 분석했습니다.
리뷰되지 않은 파일이나 코드가 존재할 수 있으니 사람이 추가로 확인해야 합니다.

1. [HIGH] 탈퇴한 사용자에 대한 차단 해제 불가 및 요청자 검증 누락

  • 파일: src/main/java/com/server/block/service/BlockService.java
  • 라인: 57-62
  • 문제점:
    unblock 메서드에서 차단 해제 대상자(targetUserId)가 활성 상태인지 검증하기 위해 findActiveUser(targetUserId)를 호출하고 있습니다. 만약 차단된 사용자가 그 사이에 회원 탈퇴를 하여 deleted_at이 설정된 경우, findActiveUser 내부에서 USER_NOT_FOUND 예외를 던지게 되므로 요청자는 해당 사용자에 대한 차단 해제(unblock)를 수행할 수 없는 상태에 빠지게 됩니다.
    또한, 차단 해제를 요청한 본인(userId)이 활성 상태인지 검증하는 로직이 누락되어 있어 비정상적인 요청을 방어하지 못합니다.
  • 개선 제안:
    차단 해제 시에는 대상 사용자의 탈퇴 여부와 관계없이 차단 관계를 해제할 수 있도록 targetUserId에 대한 findActiveUser 검증을 제거해야 합니다. 대신 요청자(userId)가 현재 활성 상태인지 검증하는 로직을 추가해야 합니다.
  • 개선 예시:
    /** 차단을 풀어도 끊긴 팔로우는 되살리지 않는다. */
    @Transactional
    public BlockResponse unblock(Long targetUserId, Long userId) {
        // 요청자 본인이 활성 사용자인지 검증
        findActiveUser(userId);
        
        // 대상 사용자의 활성 여부와 상관없이 차단 관계 해제 진행
        blockRepository.deleteByBlockerIdAndBlockedId(userId, targetUserId);
        return new BlockResponse(false);
    }

2. [HIGH] 특정 DBMS 종속적인 네이티브 쿼리로 인한 호환성 및 테스트 실패 가능성

  • 파일: src/main/java/com/server/block/repository/BlockRepository.java
  • 라인: 34-40
  • 문제점:
    insertIfAbsent 메서드에 작성된 네이티브 쿼리는 PostgreSQL 등 특정 DBMS에서만 지원하는 on conflict do nothing 구문을 사용하고 있습니다. 만약 로컬 개발 환경이나 테스트 환경에서 H2 데이터베이스를 사용하거나 운영 환경에서 MySQL 등을 사용하는 경우, 이 쿼리는 문법 오류(SQLGrammarException)를 발생시켜 애플리케이션 실행 및 테스트(ConflictSafeInsertTest 등)가 실패하게 됩니다.
  • 개선 제안:
    특정 DBMS에 종속적인 네이티브 쿼리 대신, JPA 표준 기능을 사용하고 유니크 제약 조건 위배 예외(DataIntegrityViolationException)를 서비스 레이어에서 캐치하여 무시하는 방식으로 처리하는 것이 안전합니다.
  • 개선 예시:
// BlockRepository.java 에서 insertIfAbsent 메서드 제거

// BlockService.java
    @Transactional
    public BlockResponse block(Long targetUserId, Long userId) {
        if (targetUserId.equals(userId)) {
            throw new BusinessException(ErrorCode.INVALID_BLOCK_REQUEST);
        }
        User blocker = findActiveUser(userId);
        User blocked = findActiveUser(targetUserId);

        try {
            blockRepository.saveAndFlush(new Block(blocker, blocked));
        } catch (DataIntegrityViolationException e) {
            // 이미 차단된 경우(유니크 제약 조건 위배) 예외를 무시하여 멱등성 보장
        }

        followRepository.deleteByFollowerIdAndFollowingId(userId, targetUserId);
        followRepository.deleteByFollowerIdAndFollowingId(targetUserId, userId);

        return new BlockResponse(true);
    }

3. [MEDIUM] API 요청 size 파라미터의 최대값 검증 누락

  • 파일: src/main/java/com/server/block/controller/BlockController.java
  • 라인: 70
  • 문제점:
    getMyBlocks API의 size 파라미터에 @Min(1) 검증만 적용되어 있고, API 명세(API_SPEC.md)에 명시된 최대값 제한인 50에 대한 @Max(50) 검증이 누락되어 있습니다. 이로 인해 클라이언트가 의도적 혹은 실수로 매우 큰 size 값을 보낼 경우, 대량의 데이터 조회로 인해 데이터베이스 및 애플리케이션 서버에 과도한 부하가 발생할 수 있습니다.
  • 개선 제안:
    size 파라미터에 @Max(50) 어노테이션을 추가하여 입력값을 제한하십시오.
  • 개선 예시:
            @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20")
            @RequestParam(defaultValue = "20") @Min(1) @Max(50) Integer size

Model: `gemini-3.5-flash` · API key: `PRIMARY` · Commit: `b3ebeb0`

@sususuj
sususuj requested a review from SD-gif August 23, 2026 11:27
@SD-gif

SD-gif commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

코드 리뷰

설계 근거가 잘 잡혀 있습니다. 집계 컬럼 DB 직접 증감, 멱등 처리, PostPlaceTagView로 N+1 회피, 커서·오프셋 페이징 구분, 삭제된 부모 댓글 자리 유지까지 이유를 갖고 만든 게 보입니다. 아래는 그 위에서 발견한 것들입니다.

머지 전에 고쳤으면 하는 것

1. 소프트 삭제한 게시물의 해시태그가 영구 소실됩니다PostService.java:199

Post.delete() 주석과 deleteKeepsRowForRecovery 테스트가 "30일간 복구 가능"을 전제로 하는데, 같은 메서드가 hashtagService.detachFromPost()post_hashtags 행을 물리 삭제합니다.

#부산 태그가 달린 글을 지웠다 복구하면 본문에는 #부산이 남아 있지만 링크 행이 없어 ?hashtag=부산 필터에서 영원히 빠집니다. post_media·post_place_tags는 그대로 두는 것과도 일관되지 않습니다. 복구 시 재계산하거나, 링크도 소프트 삭제하는 편이 맞아 보입니다.

2. 오류 코드 — 물어보신 건 고치는 게 맞습니다GlobalExceptionHandler.java:209

POST /api/v1/posts에 빈 content를 보내면 INVALID_SCHEDULE_CONDITION / "일정 조건이 올바르지 않습니다"가 나갑니다. 본문을 받는 커뮤니티 API 전부 동일합니다. /api/v1/posts, /api/v1/users, /api/v1/comments 분기 추가해 주세요. 공용 파일이지만 이건 명백한 누락이라 이 PR에서 처리하는 게 낫습니다.

3. 해시태그가 어떤 응답에도 안 담깁니다HashtagService.java:94

추출·저장·post_count 관리·필터까지 다 되는데 PostDetailResponsePostSummaryResponsehashtags 필드가 없습니다. findNamesByPostIds는 이걸 위해 만들어졌지만 호출부가 0곳입니다.

상세 화면에서 태그를 못 보여주고, 수정 화면에서 현재 태그를 표시할 수도 없습니다. 태그를 눌러 필터 피드로 가는 동선이 서버 응답만으로는 구현 불가입니다.

4. 입력 상한이 없습니다PostCreateRequest.java:15

content@NotBlank만 있고 @Size가 없습니다. 컬럼이 text라 DB도 안 막아서 수 MB 본문이 그대로 저장되고, 이후 모든 피드 응답이 그 글을 포함해 부풀어 오릅니다. mediaList도 원소 수 제한이 없고, mediaType은 자유 문자열이라 IMAGE/VIDEO가 아닌 값도 저장됩니다. CommentCreateRequest.content도 같습니다.

판단이 필요한 것

5. 탈퇴 사용자의 게시물이 계속 조회됩니다PostService.java:157, BookmarkService.java:68

이 두 곳만 existsById를 씁니다. 나머지는 전부 findByIdAndDeletedAtIsNull입니다. 탈퇴한 사용자의 GET /users/{id}는 404인데 GET /users/{id}/posts는 200에 글 목록이 그대로 나옵니다.

6. 차단이 끊은 팔로우를 바로 다시 맺을 수 있습니다FollowService.java:40

block()은 "차단한 상대의 소식을 계속 받는 것은 앞뒤가 맞지 않는다"며 양방향 팔로우를 끊는데, follow()에는 차단 확인이 없습니다. 차단 직후 팔로우 API 한 번이면 원복됩니다.

7. 차단 필터가 피드에만 있습니다PostService.java:94

findFeed·findPopularFeed에는 있지만 상세 조회와 사용자 게시물 목록에는 없습니다. 피드에서 본 글을 저장해 뒀다가 링크로 들어가면 차단이 무의미해집니다. 의도한 범위라면 API_SPEC.md에 "차단은 피드에만 적용"을 명시해 주세요.

8. 동시 요청에서 멱등성이 깨집니다PostService.java:219

exists 확인 후 저장하는 구조라, 좋아요 버튼을 빠르게 두 번 누르면 양쪽 다 exists=false를 읽고 각각 INSERT를 시도합니다. post_likes PK 충돌로 뒤늦은 쪽이 500이 됩니다. 저장을 시도하고 충돌을 무시하는 편이 낫습니다. bookmark, follow, block, CommentService.like도 같은 구조입니다.

사소한 것

9. HashtagSeedInitializer.java:38 — count 후 insert라 인스턴스 두 개가 동시에 뜨면 uk_hashtags_name에 걸립니다. ApplicationRunner 예외는 기동 실패로 이어집니다. on conflict (name) do nothing으로 바꾸면 확인 쿼리 38회도 함께 없앨 수 있습니다.

10. ReportService.java:43 — 중복 신고를 코드로만 막고 reports(reporter_id, target_type, target_id) 고유 제약이 없습니다.


1·2번은 각각 기획 요구사항과 직접 충돌하고 31개 API 전체의 오류 응답에 영향이 있어 머지 전 처리를 권합니다. 나머지는 후속으로 가도 됩니다.

@sususuj

sususuj commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

피드백 주신 부분 수정 내용입니다!
1. 해시태그 소실 — 복구 API를 만들어 해결했습니다. 삭제 시 연결을 지우는 건 유지하고, 복구할 때 본문에서 다시 뽑아 연결합니다.
작업하면서 보니 소프트 삭제만 있고 되살리는 수단이 없어 기획의 "30일 복구"가 동작하지 않는 상태였습니다. GET /posts/me/deleted와 POST /posts/{postId}/restore를 추가했고, 기한이 지나면
실제로 지우는 정리 스케줄러도 넣었습니다. 게시물과 함께 미디어·장소 태그·댓글·댓글 좋아요·좋아요·저장·해시태그 연결을 외래키 순서대로 지웁니다.
배포 서버에 COMMUNITY_POST_PURGE_ENABLED=true가 필요합니다. 기본값이 false라 켜지 않으면 지운 게시물이 계속 쌓입니다. 이 때문에 application.yaml에 app.community.post-purge를
추가했습니다. 기존 설정은 건드리지 않았습니다.

2. 오류 코드 — INVALID_POST_REQUEST, INVALID_USER_REQUEST를 추가해 세 경로로 나눴습니다. 댓글 경로가 게시물 하위라 먼저 보도록 순서를 뒀습니다.
같은 핸들러의 다른 누락도 고쳤습니다. mediaType에 잘못된 값을 보내면 fieldErrors가 항상 body였는데, 이제 필드명과 쓸 수 있는 값을 알려줍니다. 공용 파일을 예정보다 더 건드린 부분이라
문제되면 되돌리겠습니다.

3. 해시태그 응답 — 상세와 목록에 hashtags를 추가했습니다. 배치로 읽어 게시물 수가 늘어도 쿼리는 한 번입니다.

4. 입력 상한 — 본문 2000자, 댓글 1000자, 미디어 10건, 장소 태그 10건, URL 2048자입니다. mediaType은 열거형으로 바꿨습니다.

5. 탈퇴 사용자 — 두 곳만 고치면 프로필은 404인데 피드에는 남아서, 조회 경로 전체에 작성자 상태 조건을 넣었습니다. 수정·삭제는 요청자가 곧 작성자라 제외했습니다.
이 작업을 하다 제가 버그를 만들었고 리뷰 덕분에 찾았습니다. 조건을 그냥 AND로 걸었더니 최상위 댓글 작성자가 탈퇴하면 답글까지 사라졌습니다. 지적하셨던 삭제 댓글 문제와 같은 상황이었습니다. 지금은 똑같이 자리를 남기고 내용만 비우며, hiddenReason(DELETED, WITHDRAWN)으로 화면 문구를 구분할 수 있게 했습니다.

6. 차단 후 재팔로우 — 내가 차단한 상대를 팔로우하면 400 FOLLOW_BLOCKED_USER입니다. 나를 차단한 상대가 팔로우하면 200을 주되 관계를 만들지 않습니다. 거절하면 차단당한 사실이 드러나기때문입니다.

7. 차단 범위 — 확인해 보니 제 판단이 틀렸습니다. 인스타그램은 차단한 사용자의 댓글을 제3자 게시물에서 숨기지 않습니다. 차단은 상대가 내 계정에 접근하지 못하게 하는 것이라서요.
그래서 숨기기 대신 쓰기 쪽을 막았습니다. 이쪽이 통째로 없어서 차단해도 상대가 내 글에 댓글을 계속 달 수 있었습니다. 차단 관계면 403 COMMENT_NOT_ALLOWED이고, 상세·프로필 직접 열람과 제3자
게시물의 댓글은 막지 않습니다. 범위를 API_SPEC.md C-9에 명시했습니다.

8. 동시성 — insert ... on conflict do nothing으로 바꿨습니다. 좋아요, 댓글 좋아요, 북마크, 팔로우, 차단 다섯 곳이며 좋아요는 실제 들어간 행 수를 보고 집계를 올립니다.

9. batchUpdate + on conflict do nothing으로 바꿔 기동 시 확인 쿼리 38회를 없앴습니다.

10. V9__add_reports_unique_constraint.sql로 고유 제약을 추가했습니다. 제약 전에 기존 중복 행을 지우는 DELETE를 뒀습니다.

계정 탈퇴 정책
되돌릴 수 없게 두었습니다. 되살릴 수 있는 것은 본인이 지운 게시물뿐입니다. 다만 탈퇴자 댓글을 감추면서 comment_count는 줄이지 않아 화면 개수와 목록이 어긋납니다. 좋아요도 탈퇴자가 누른
것이 숫자에 남습니다. 탈퇴 시 집계를 함께 줄이는 게 맞다고 보는데, auth-and-admin-spec.md의 "탈퇴 처리" 결정에 포함해주시면 커뮤니티 쪽을 맞추겠습니다.

검증

./gradlew test

BUILD SUCCESSFUL, 162건 통과 (기존 140 + 신규 22), 실패 0

마이그레이션(now at version v9)과 ddl-auto=validate 통과를 확인했고, 복구·차단·검증 동작을 로컬에서 실제 호출로 확인했습니다. main을 머지했으며 api-change-log.md 충돌은 날짜순으로 정리해
양쪽 내용 모두 남겼습니다.

@SD-gif

SD-gif commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

재리뷰 — 지적 10건 중 9건 반영 확인

대응이 꼼꼼합니다. 특히 세 가지는 제가 지적한 것보다 더 나은 방향으로 가셨습니다.

복구 API 를 새로 만드셨다 — 저는 "삭제 시 해시태그가 물리 삭제돼 복구하면 태그가 사라진다"고만 지적했는데, 복구 API 와 정리 스케줄러를 함께 만들어 restore() 에서 본문으로 태그를 다시 붙이도록 하셨습니다. 30일 뒤 실제로 지우는 경로까지 생겨서 "지운 글이 영원히 쌓이는" 문제도 같이 닫혔습니다.

차단당한 사실을 숨기신 것follow() 에서 내가 차단한 상대는 거절하되, 상대가 나를 차단한 경우는 관계만 만들지 않고 응답은 성공과 동일하게 주는 처리입니다. 제 지적에는 없던 고려입니다.

동시성insertIfAbsenton conflict do nothing 을 쓰신 게 맞습니다. 확인 후 저장보다 정확합니다.

반영 확인한 항목입니다.

지적 상태
1. 소프트삭제 해시태그 물리삭제 복구 API 에서 재부착
2. 오류 코드 분기 /posts·/comments·/users 추가
3. 해시태그 미노출 상세·피드 응답에 반영, findNamesByPostIds 연결
4. 입력 상한 @Size 추가
5. 탈퇴 사용자 existsByIdAndDeletedAtIsNull 로 교체
6. 차단 후 재팔로우 차단 확인 추가
7. 차단 범위 문서에 명시
8. 동시 좋아요 500 insertIfAbsent
9. 시드 동시 부팅 on conflict do nothing
10. 신고 중복 제약 미반영

새로 발견한 것

1. 정리 스케줄러가 배포되면 아예 안 돕니다 — .github/workflows/deploy-dev.yml

PostPurgeScheduler@ConditionalOnProperty(havingValue = "true") 이고 기본값이 COMMUNITY_POST_PURGE_ENABLED:false 입니다. 그런데 deploy-dev.yml.env.server 생성부에 이 변수가 없습니다.

배포하면 스케줄러 빈이 아예 등록되지 않아 정리가 한 번도 돌지 않습니다. PostPurgeService 주석이 말하는 "사용자가 지웠다고 믿는 본문과 사진 URL 이 DB 에 그대로 남는" 상태가 그대로 유지됩니다.

이 저장소에 같은 사고가 있었습니다. SCHEDULE_FASTAPI_ENABLED 를 배포에 안 넣어서 일정 API 가 전부 503 이었고, 그때도 기본값이 false 였습니다.

2. 정리가 하루 100건에서 멈춰 밀린 분량이 쌓입니다 — PostPurgeService.java:71

BATCH_SIZE = 100 으로 한 페이지만 읽고 끝납니다. 스케줄러는 하루 한 번 부르고 반환값은 로그로만 남깁니다.

하루에 150건이 기한을 넘기면 100건만 지워지고 50건이 남습니다. 다음 날 또 150건이 만료되면 밀린 양이 매일 50건씩 늘어납니다. 로그에는 "정리를 마쳤다" 로 보입니다.

반환값이 BATCH_SIZE 미만이 될 때까지 반복하면 해결됩니다.

3. 해시태그 연결만 충돌에 안전하지 않습니다 — HashtagService.java:53

좋아요·팔로우·저장은 insertIfAbsent 로 바꾸셨는데 attachFromContent 는 여전히 saveAll 입니다.

복구 버튼을 빠르게 두 번 누르면 두 트랜잭션이 모두 findDeletedById 로 삭제된 글을 찾고(앞선 쪽이 커밋되기 전이라 둘 다 보입니다) 각각 같은 (post_id, hashtag_id) 를 INSERT 합니다. 뒤늦은 쪽이 기본키 위반으로 500 이 됩니다. 게시물 수정을 동시에 두 번 저장해도 같습니다.

이번에 다른 곳에 적용한 on conflict do nothing 을 여기도 쓰면 일관됩니다.

4. 신고 중복 제약이 아직 없습니다 — V8__create_community_tables.sql:88

reports(reporter_id, target_type, target_id) 고유 제약이 없어 코드의 exists 확인만으로 막고 있습니다. 동시 요청이면 두 행이 들어갑니다. 좋아요·팔로우를 DB 제약에 맡기도록 바꾸셨으니 신고도 같은 방식이 자연스럽습니다.

5. 완전 삭제가 hashtags.post_count 를 되돌리지 않습니다 — PostPurgeService.java:92

purge()hashtagService.detachFromPost 대신 postHashtagRepository.deleteByPostId 를 직접 부릅니다. 주석대로 이미 끊긴 상태라면 무해하지만, detach 가 실패해 롤백됐거나 예전 데이터가 남아 있으면 링크만 사라지고 post_count 는 그대로입니다.

post_count 는 자동완성 정렬 기준이라 한 번 부풀면 실제로 덜 쓰이는 태그가 계속 상단에 노출되고 되돌릴 근거도 없습니다. detachFromPost 를 쓰면 링크 삭제와 카운트 감소가 함께 일어납니다.


1번이 가장 급합니다. 기능을 다 만들어 놓고 배포하면 동작하지 않는 상태라, 환경변수 한 줄이면 됩니다. 나머지는 후속으로 가도 됩니다.

인증 기반(#81)이 먼저 머지되면서 생긴 충돌을 해소한다.
자동 병합이 조용히 깨뜨리는 곳이 있어 하나씩 확인했다.

ErrorCode
  양쪽이 USER_NOT_FOUND 를 각자 추가해 자동 병합 결과에 같은 상수가 두 번 들어갔다.
  그대로 두면 컴파일이 되지 않는다. 하나만 남긴다.

UserRepository
  양쪽이 findByIdAndDeletedAtIsNull 을 추가했다. 나머지 조회 메서드는 서로 다르므로
  둘 다 남긴다.

GlobalExceptionHandler
  커뮤니티(posts·users·comments)와 인증(auth) 분기를 함께 둔다. 댓글 경로가
  /api/v1/posts/{postId}/comments 라 게시물보다 먼저 봐야 하는 순서를 그대로 지킨다.

User
  updateProfile 이 changeNickname·changeProfileImage 로 나뉘어, 이를 쓰던
  OAuthUserRegistrarTest 를 새 메서드로 바꾼다.

ERD
  같은 14절에 양쪽이 다른 users 표를 썼다. 인증 컬럼이 반영된 쪽을 쓰고 커뮤니티
  15~26절을 이어 붙인다.

migration 번호
  양쪽이 V9 를 썼다. 두 파일이 같은 버전이면 Flyway 가 기동 자체를 거부한다.
  인증 쪽 V9·V10 은 이미 dev 에 적용돼 번호를 바꿀 수 없으므로 신고 고유 제약을
  V11 로 옮긴다.

전체 218건 통과. 실제 PostgreSQL 에 전체 migration 적용과 JPA 스키마 검증을 확인했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SD-gif

SD-gif commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

정정과 충돌 해소

먼저, 재리뷰 지적 4번은 제가 틀렸습니다

"신고 중복 제약이 없다"고 적었는데, V9__add_reports_unique_constraint.sql 로 이미 넣어 두셨더군요. 제가 V8 만 보고 별도 migration 을 확인하지 않았습니다.

재리뷰 지적 5건 중 실제로 남은 것은 4건입니다. 죄송합니다.

충돌은 제가 해소해서 푸시했습니다

인증 기반(#81)을 먼저 머지하면서 이 PR 에 충돌을 만들었습니다. 제가 만든 문제라 제가 풀었습니다.

자동 병합이 조용히 깨뜨리는 곳이 세 군데 있었습니다.

ErrorCode — 양쪽이 USER_NOT_FOUND 를 각자 추가해서, 자동 병합 결과에 같은 상수가 두 번 들어갔습니다. 그대로 두면 컴파일이 안 됩니다.

UserRepository — 양쪽이 findByIdAndDeletedAtIsNull 을 추가했습니다. 나머지 메서드는 서로 달라 둘 다 남겼습니다.

migration 번호 — 양쪽이 V9 를 썼습니다. 이게 가장 위험했습니다. 같은 버전 파일이 둘이면 Flyway 가 기동 자체를 거부합니다. 인증 쪽 V9·V10 은 이미 dev 에 적용돼 번호를 바꿀 수 없어서, 신고 고유 제약을 V11 로 옮겼습니다. 내용은 그대로입니다.

나머지는 양쪽 내용을 합쳤습니다.

GlobalExceptionHandler — 커뮤니티(posts·users·comments)와 인증(auth) 분기를 함께 뒀습니다. "댓글 경로가 /posts/{postId}/comments 라 게시물보다 먼저 본다"는 순서를 그대로 지켰습니다.

UserupdateProfilechangeNickname·changeProfileImage 로 나뉘어서, 이를 쓰던 제 OAuthUserRegistrarTest 를 새 메서드로 바꿨습니다.

ERD — 같은 14절에 양쪽이 다른 users 표를 썼습니다. 인증 컬럼이 반영된 쪽을 쓰고 커뮤니티 15~26절을 이어 붙였습니다.

검증

./gradlew test218건 통과. Testcontainers 로 실제 PostgreSQL 에 V1~V11 전체 적용과 JPA 스키마 검증까지 확인했습니다.

남은 지적 4건

머지를 막을 사유는 아니라고 봅니다. 다만 1번은 후속에서 꼭 처리해야 합니다.

  1. COMMUNITY_POST_PURGE_ENABLED 가 배포 워크플로에 없어 정리 스케줄러가 배포되면 아예 안 돕니다. 만들어 둔 기능이 무해하게 죽어 있는 상태로 올라갑니다.
  2. 정리가 한 번에 100건에서 멈춰 밀린 분량이 쌓입니다.
  3. 해시태그 연결만 충돌 안전하지 않아 복구·수정 더블클릭 시 500 이 납니다.
  4. 완전 삭제가 hashtags.post_count 를 되돌리지 않습니다.

@SD-gif
SD-gif merged commit bfac5cb into main Aug 25, 2026
2 checks passed
@SD-gif
SD-gif deleted the feature/community-tables branch August 25, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants